GDB 실전 디버깅 (멀티스레드·시그널)

NOTE

기본 명령어부터 멀티스레드 디버깅(info threads/thread apply all bt로 원인 스레드 찾기), 블로킹 함수를 n으로 넘길 때의 함정, gdb가 SIGINT를 가로채서 Ctrl+C 테스트가 안 먹히는 문제까지 정리한다.

1. 기본 명령어

명령어사용법설명비고
gdb [실행파일]gdb ./xxx.out바이너리를 붙여서 디버깅 시작코어 덤프 분석은 gdb ./xxx.out core
rr프로그램 시작(run)
b [위치]b main, b file.c:42브레이크포인트 설정
nn다음 줄 실행(next) — 함수 호출은 안으로 안 들어가고 통째로 실행아래 3번 항목 주의
ss다음 줄 실행(step) — 함수 호출이면 그 안으로 들어감
cc브레이크포인트/정지 지점부터 계속 실행(continue)
untiluntil반복문 끝까지 한 번에 실행
finishfinish현재 함수가 리턴할 때까지 실행
p / printp 변수명변수/표현식 값 확인
btbt현재 스레드의 콜스택(backtrace) 확인
up / downup, down 2콜스택 프레임 이동다른 함수의 지역변수 보려면 그 프레임으로 가야 함
frame [N]frame 2콜스택의 N번 프레임으로 직접 이동
qqgdb 종료
xx/32x 주소, x/s 문자열주소메모리를 직접 덤프 (x/<개수><포맷><단위>)포맷: x(16진), d, s(문자열), i(명령어) / 단위: b,h,w,g. 포인터 변수면 x/32x ptr
Ctrl+X, ACtrl+X 누른 뒤 ATUI 모드 토글 — 소스 코드 창을 같이 띄워서 보며 디버깅한 번 더 누르면 원래 CLI로 복귀. 화면이 깨지면 Ctrl+L

보조 — TUI 모드와 메모리 확인

  • TUI 모드: Ctrl+X, A로 켜면 위쪽에 소스 창, 아래쪽에 gdb 프롬프트가 나뉘어 현재 실행 줄을 눈으로 따라가며 n/s로 진행할 수 있다.
  • s vs n: 디버그 심볼이 없는 라이브러리 API는 s로 들어가도 소스가 안 보이고 어셈블리로 들어간다 — 이럴 땐 finish로 빠져나온다.
  • 메모리 확인: x/32x buf로 버퍼를 16진으로 확인한다. 가변 길이 바이너리(length + 가변 바이트 구조)처럼 끝을 알 수 없는 데이터는 p로 문자열처럼 보지 말고 length 필드만큼 x/<length>xb로 덤프하는 게 정확하다.

2. 멀티스레드 프로그램 디버깅

스레드가 여러 개인 프로그램은 “지금 실행 중인 스레드”만 보이는 게 아니라, 전체 스레드 목록과 각 스레드의 상태를 따로 다뤄야 한다.

명령어설명
info threads전체 스레드 목록 + 각 스레드가 지금 멈춰있는 위치
thread NN번 스레드로 전환 (gdb 내부 번호. OS의 LWP 번호와 다름)
thread apply all bt모든 스레드의 콜스택을 한 번에 출력 — 어떤 스레드가 어디서 멈춰있는지 전체 그림을 볼 때 제일 먼저 쓰게 되는 명령

실전 흐름 — “종료 플래그를 세웠는데 프로세스가 안 끝난다”

멀티스레드 데몬에서 종료 플래그(running 같은 변수)를 0으로 만들었는데도 프로세스가 안 끝날 때, thread apply all bt로 전체 스레드를 먼저 훑어보면 원인이 대략 세 부류로 나뉜다.

Thread 4: nanosleep() ... worker_thread()      <- 주기적으로 자다가 깨서 플래그 재확인하는 정상 패턴
Thread 3: pthread_join() ... main()            <- 다른 스레드가 끝나길 기다리는 중 (메인)
Thread 2: poll() ... (라이브러리 내부 스레드)     <- 우리 코드가 만든 스레드가 아닐 수 있음
  • sleep()/nanosleep() 안에 있는 스레드: 주기적으로 깨서 플래그를 다시 확인하는 정상 루프 구조라면, 그 주기(예: 3초)만큼만 기다리면 스스로 빠져나온다. 버그가 아니라 타이밍 문제일 뿐.
  • 소켓/IPC 통신 함수(send, recv, 라이브러리의 통신 API 등) 안에 있는 스레드: 루프 조건을 맨 위에서만 확인하는 구조라면, 통신 함수 자체가 응답을 못 받아 오래 블로킹되는 동안은 플래그를 아무리 바꿔도 그 루프가 재확인할 기회가 없다 — 이건 진짜 구조적 문제(타임아웃 처리가 없는 블로킹 호출)일 가능성이 높다.
  • 우리가 만들지 않은 스레드(라이브러리/프레임워크가 내부적으로 띄운 스레드): join하는 대상이 아니면 신경 쓸 필요 없다. main()이 리턴/exit()하면 프로세스 종료 시 다른 스레드는 상태와 무관하게 정리된다.

같은 변수를 다른 스레드/프레임에서 값이 다르게 보이는 것 같으면, 대부분은 이름이 같은 서로 다른 변수를 보고 있는 경우다. 예를 들어 여러 함수가 각자 지역변수로 ctx라는 이름을 따로 쓰고 있으면, 프레임을 안 맞추고 print ctx를 하면 완전히 다른 타입/다른 스레드의 값을 보게 된다. 헷갈리면:

frame 2          /* 확인하고 싶은 변수가 선언된 함수의 프레임으로 이동 */
print 변수명

으로 프레임을 먼저 맞추고 확인하는 습관이 필요하다.

3. 블로킹 함수를 n으로 넘길 때의 함정

n(next)은 “그 줄에 있는 함수 호출을 안으로 들어가지 않고 통째로 실행”하는 명령인데, 그 함수가 블로킹 함수(예: 다른 스레드를 기다리는 join, 표준입력을 기다리는 함수 등)라면 그 호출이 끝날 때까지 gdb 프롬프트가 안 돌아온다.

이 상태에서는 터미널 입력이 gdb 명령이 아니라 디버깅 대상 프로그램의 입력으로 그대로 들어간다. 그래서 이 타이밍에 뭔가 쳤는데 gdb 명령으로 인식 안 되고 프로그램이 “그런 명령 없음” 식으로 반응한다면, gdb가 안 먹은 게 아니라 입력이 프로그램 쪽으로 가고 있는 것이다. gdb 명령을 다시 치려면, 블로킹이 실제로 풀리길 기다리거나(그 함수가 리턴할 조건을 채워주거나) Ctrl+C로 강제로 끊고 들어가야 한다.

4. 시그널과 gdb — Ctrl+C가 프로그램에 안 갈 수 있다

Ctrl+C(SIGINT)로 프로그램 자체의 종료 처리를 테스트하려는 경우, gdb 안에서 하면 원하는 대로 안 될 수 있다.

gdb는 기본적으로 SIGINT를 “디버거를 멈추는 용도”로 가로채고, 프로그램한테는 전달하지 않는다 (Stop=Yes, Pass=No). 그래서 c(continue)로 실행 중에 Ctrl+C를 누르면:

Program received signal SIGINT, Interrupt.

라는 메시지와 함께 gdb 프롬프트로 돌아오는데, 이건 gdb가 멈춘 것이지 프로그램의 시그널 핸들러가 실행된 게 아니다. 이 상태에서 아무리 여러 번 Ctrl+C를 눌러도 프로그램이 등록한 시그널 핸들러(예: 종료 플래그를 0으로 만드는 핸들러)는 한 번도 호출되지 않는다.

실제 시그널 처리를 테스트하는 방법 두 가지:

  1. gdb 밖에서 바이너리를 직접 실행하고 Ctrl+C — 가장 확실하고 간단함
  2. gdb 안에서 하고 싶다면, continue 하기 전에 아래 설정으로 gdb가 시그널을 가로채지 않고 그대로 넘기도록 바꿔줘야 함:
    handle SIGINT nostop pass noprint
    
    이후 c로 실행하고 Ctrl+C를 누르면, 이번엔 gdb가 멈추지 않고 시그널이 프로그램에 실제로 전달돼서 핸들러가 동작하는지 확인할 수 있다.

관련 문서